feat: background workers = non-HTTP workers with shared state#2287
feat: background workers = non-HTTP workers with shared state#2287nicolas-grekas wants to merge 11 commits intophp:mainfrom
Conversation
e1655ab to
867e9b3
Compare
|
Interesting approach to parallelism, what would be a concrete use case for only letting information flow one way from the sidekick to the http workers? Usually the flow would be inverted, where a http worker offloads work to a pool of 'sidekick' workers and can optionally wait for a task to complete. |
da54ab8 to
a06ba36
Compare
|
Thank you for the contribution. Interesting idea, but I'm thinking we should merge the approach with #1883. The kind of worker is the same, how they are started is but a detail. @nicolas-grekas the Caddyfile setting should likely be per |
ad71bfe to
05e9702
Compare
|
@AlliBalliBaba The use case isn't task offloading (HTTP->worker), but out-of-band reconfigurability (environment->worker->HTTP). Sidekicks observe external systems (Redis Sentinel failover, secret rotation, feature flag changes, etc.) and publish updated configuration that HTTP workers pick up on their next request; with per-request consistency guaranteed via Task offloading (what you describe) is a valid and complementary pattern, but it solves a different problem. The non-HTTP worker foundation here could support both. @henderkes Agreed that the underlying non-HTTP worker type overlaps with #1883. The foundation (skip HTTP startup/shutdown, immediate readiness, cooperative shutdown) is the same. The difference is the API layer and the DX goals:
Happy to follow up with your proposals now that this is hopefully clarified. |
05e9702 to
8a56d4c
Compare
|
Great PR! Couldn't we create a single API that covers both use case? We try to keep the number of public symbols and config option as small as possible! |
Yes, that's why I'd like to unify the two API's and background implementations into one. Unfortunately the first task worker attempt didn't make it into |
|
The PHP-side API has been significantly reworked since the initial iteration: I replaced The old design used
Key improvements:
Other changes:
|
cb65f46 to
4dda455
Compare
|
Thanks @dunglas and @henderkes for the feedback. I share the goal of keeping the API surface minimal. Thinking about it more, the current API is actually quite small and already general:
The name "sidekick" works as a generic concept: a helper running alongside. The current set_vars/get_vars protocol covers the config-publishing use case. For task offloading (HTTP->worker) later, the same sidekick infrastructure could support:
Same worker type, same So the path would be:
The foundation (non-HTTP threads, cooperative shutdown, crash recovery, per-php_server scoping) is shared. Only the communication primitives differ. WDYT? |
b3734f5 to
ed79f46
Compare
|
|
|
Hmm, it seems they are on some versions, for example here: https://github.com/php/frankenphp/actions/runs/23192689128/job/67392820942?pr=2287#step:10:3614 For the cache, I'm not aware of a Github feature that allow to clear everything unfortunately 🙁 |
Not necessarily the public webroot, but a root defined by the Caddyfile for sure. The problem with your approach is that it limits to a single background worker script, which is likely in a framework like Symfony with a central kernel and container, but otherwise not.
It's a fair point, but again, I'm not concerned with security when it's explicitly configurable through some background-worker-directory. We're not giving anyone a gun here, they're stealing it, pointing it at their foot and fire repeatedly when someone actually manages to run into a security issue with it. And they could do the same with a single entrypoint, too.
While adding API surface is true, but it's unified api surface that we'd most likely add at some point anyways. Then it's better to have an explicit API than magic behaviour on one, but not the other.
That's a very fair point that I don't have a perfect solution to. It's actually one where I'm going back and fourth between even using names (and just using anonymous lists) in another project I'm working on.
See point 1, because at that point it would just be confusing about what issues a lazy start and what doesn't. (And I'm honestly not even sure how useful a lazy start really is, what problem does that solve? The library will have a dependency on the Caddyfile configuration at that point and the worker existing if it's hit once, and if it is, it would never shut down again)
These are all more or less the same point of disagreement which is: this PR is locking that decision in "forever". No generic KV store, no matter if it would ever make sense (I'd argue it would, how else would you share vars within the same application on different threads, but guard it from being accessed by other, unrelated applications? Using apcu for this is very dirty and will suffer from heavy fragmentation for a runtime concern. I think @alexandre-daubois essentially has the same considerations that Alex and I do too.
It would obviously be blocking until started, but it would a generic API surface that could be reused for task workers, that we're still intending to add. And it would be explicit. And it would solve the inability to reason about what a unified
I just think the actual issue with it is the same as before: worker string names lead to poor reasoning. If library A uses 'redis' and library B uses the same, but both expect different worker scripts, we have the exact same issue that the many-writers, many-readers has. If we don't have conflicting worker names, there's no issue with many-writers-many-readers either. |
|
Marc has already articulated most of where I land, so I'll stay short and add a few angles I don't think have come up yet. On the DNS / Redis / Symfony service name analogy, I think the comparison doesn't hold. Those APIs deliberately separate concerns: DNS has About testability: a global function whose call can spawn a worker process is hostile to unit tests. Libraries adopting this will either need to wrap it in their own abstraction or give up on isolation in tests. A store-shaped API is materially more mockable. Also, about the principle of least surprise: Finally, genuine question about the API: is it possible to unset a key? |
|
Edited: I missed last response by @alexandre-daubois and I agree with him. API updated. Thanks, everyone, for the depth of this one! @nicolas-grekas for the huge amount of work, and @henderkes, @AlliBalliBaba, @alexandre-daubois, @dbu for the careful pushback. I've read through the whole thread, and I think we're close to merging it. Most of the back-and-forth is really Here's my opinion on this: Caddyfile and the whole Go/C runtime stay as Nicolas designed them, but we make small changes to the PHP API:
On unsetting a key: with snapshot semantics it's just set_vars a new array without the key — no dedicated primitive needed. If we ever add per-key writes we'd add a matching unset. We can apply the same logic for #2319, drop the
The fact that a worker picks up the task is an implementation detail. WDYT? |
|
Looks like the best of both worlds @dunglas. Dropping Sorry if this was answered somewhere in the comments: what's the defined behavior when the caller has no worker scope, e.g. called from an HTTP request context, a CLI script, or any non-worker code path? Should it be no-op or throw a |
|
I would throw too |
Why do we need a timeout for the get_vars? Shouldn't that just return immediately since the prior
Perhaps this should return an object on which php can call
You're talking about I'm generally happy with that direction, but I'd still want to argue the case for being able to define multiple background worker scripts. We went out of our way to support non-framework code all the way up until this point, for the gain I see (for a single script would already mostly disappear with an explicit |
|
Thanks @dunglas for the proposal, I think we're very close. Let me suggest a small refinement that I think fully addresses the debuggability objection without giving up anything structural. Proposal (noted about #2319 also)frankenphp_require_background_worker(string $name, float $timeout = 30.0): void
frankenphp_set_vars(array $vars): void
frankenphp_get_vars(string|array $name): array
frankenphp_get_worker_handle(): resourceFour functions, same count as your proposal. Two differences:
|
|
I don't like WDYT about |
|
We wouldn't ensure a background worker, we would ensure a background worker is running. I'm with you though, require feels wrong. I'm still in favour of |
Yes, I think we all meant
We already don't do frankenphp sapi bootup (embed instead) in the cli version. With my proposed php-src change it would use the cli sapi, still without the frankenphp extension.
I was thinking of potential worker orchestration from php side later. But thinking about it again, we could do that with streams too, so it's fine.
Sorry, I should've re-read the current version. We've been through so many iterations, at this point it's all getting a bit fuzzy, haha. No further objections from my side then. |
|
Thanks @henderkes for the follow-up confirming no further objections. On your php-src change proposal: if it lands and makes FrankenPHP CLI use the I pushed a set of refinements on top of the previous round. Summary of what changed and why: 1. Rename
|
|
The last version of the public API sounds good to me! Excellent work. |
I'm sorry, but I strongly disagree here.
When this is a real concern (and I'm not sure it is), I think we should shut down workers that haven't been asked for in a while. I'm all for keeping it as simple as possible: |
|
ensure/start I'll follow your lead - @dunglas any stronger opinion?
I agree, and that's now closer to one behavior! the only special case is failing early when a worker cannot start while http workers didn't call handle_request yet. I think that's a net safety gain that will improve robustness for ppl that can start things early, because it makes putting frankenphp live safer. The backoff mechanism of http workers will help recover from that automatically on startup when possible, while providing quicker feedback. |
|
This PR is absolutely massive. 3k loc change ... I'd argue breaking it down by scope and merge in minimal working systems, iterating as you go and paying attention to related issues so you learn the pain points users experience. For this PR ... There's so much going on, and some of it is not-obviously-wrong. There are at least 3 potential race conditions that jump out at me immediately, double close issues (which can create a security vulnerability or corruption), workers potentially getting stuck in half-started states, caddy file ordering issues, lack of synchronization, etc. Sure, many of these problems "go away" by enforcing exactly one worker thread and assuming users only use caddy to run frankenphp, but it would be a ton of work to remove that constraint if/when we want to. I'd be happy to review the whole diff, but my personal preference is to break it down. Here's where I see some seams:
You could stop here, or keep going. User demand (how can I add more instances?) gives a good reason to continue.
Each of these is independently useful, independently reviewable, and (importantly) independently revertible if a design choice turns out to be wrong. Step 4 alone covers probably 80% of what users will actually reach for. If steps 5–7 take another release or two while patterns emerge from issues, that's fine; the feature is still shipped. The other thing this buys you: each slice lets the next one's API be informed by what users actually do with the previous one. Shipping 3k lines at once locks in set_vars / get_vars / ensure / batch-names / scoping / catch-all semantics before anyone has written a single real background worker against them. This is good work, and I'm excited to see where it goes. |
Third step of the split suggested in php#2287: land the handler-interface extension point that later handler types (background workers) need, without introducing any new behaviour. Each handler gains a drain() method, called by drainWorkerThreads right before drainChan is closed. All current implementations (regularThread, workerThread, inactiveThread, taskThread) are no-ops, so observable behaviour is unchanged. A later handler that needs to wake up a thread parked in a blocking C call (e.g. by closing a stop pipe) plugs its signal in here without modifying drainWorkerThreads again. - phpthread.go: interface gains drain(). - threadregular.go / threadworker.go / threadinactive.go / threadtasks_test.go: empty drain() on each handler. - worker.go: drainWorkerThreads calls thread.handler.drain() right before close(thread.drainChan). Full test suite and caddy module tests pass under -race.
|
Thanks @withinboredom that's really useful! I think I found and fixed all the issues you described. |
Second step of the split suggested in php#2287: land the persistent-zval subsystem as a standalone, reviewable header, independent of background workers. This is the subsystem most likely to hide latent refcount or memory-lifetime bugs; reviewing it in isolation is higher-signal than finding issues inside a 3k-line diff. ## What - persistent_zval.h (renamed from the bg_worker_vars.h draft, prefix dropped for generality): - persistent_zval_validate: whitelist (scalars, arrays of allowed values, enum instances). Everything else fails fast. - persistent_zval_persist: deep-copy request -> persistent (pemalloc) memory. Fast paths baked in: interned strings shared, opcache- immutable arrays passed by pointer without copying or owning. - persistent_zval_free: deep-free; skips interned strings and immutable arrays (borrowed, not owned). - persistent_zval_to_request: deep-copy persistent -> fresh request memory. Enums re-resolved by class + case name on each read. - frankenphp.c: header included only when FRANKENPHP_TEST_HOOKS is defined. First real consumer (background workers) drops the guard. - Test hook gated on FRANKENPHP_TEST_HOOKS: - PHP function frankenphp_test_persist_roundtrip(mixed): mixed runs validate -> persist -> to_request -> free and returns the result. - Registered via zend_register_functions at MINIT so it never appears in ext_functions[] and never ships in production builds. - CI workflows set -DFRANKENPHP_TEST_HOOKS in CGO_CFLAGS (tests.yaml + sanitizers.yaml). windows.yaml is the release build, not a test runner, and stays untouched. ## Notes - Build verified both without the flag (production path, no unused-function warnings) and with it (test path). - The FRANKENPHP_TEST_HOOKS guard around the header include goes away in the PR that lands the first real caller; the test hook itself goes away in that same step once end-to-end tests cover the code paths.
Introduces a self-contained primitive that wakes a PHP thread parked in a blocking call (sleep, synchronous I/O, etc.) so the graceful drain used by RestartWorkers / DrainWorkers / Shutdown completes promptly instead of waiting for the syscall to return naturally. Design: each PHP thread, at boot from its own TSRM context, hands a force_kill_slot (pointers to its EG(vm_interrupt) and EG(timed_out) atomic bools, plus pthread_t / Windows HANDLE) back to Go via go_frankenphp_store_force_kill_slot. The slot lives on phpThread and is protected by a per-thread RWMutex so the zero-and-release path at thread exit cannot race an in-flight kill. From any goroutine, Go passes the slot back to frankenphp_force_kill_thread, which stores true into both bools (waking the VM at the next opcode boundary, routing through zend_timeout -> "Maximum execution time exceeded") and delivers a platform-specific wake-up: - Linux/FreeBSD: pthread_kill(SIGRTMIN+3) with a no-op handler installed via pthread_once, SA_ONSTACK, no SA_RESTART. Signal delivery causes the in-flight blocking syscall to return EINTR. - Windows: CancelSynchronousIo + QueueUserAPC covers alertable I/O and SleepEx. Non-alertable Sleep (including PHP's usleep) stays uninterruptible. - macOS: atomic-bool-only path. Threads stuck in blocking syscalls wait for the syscall to complete naturally. Reserved signal: SIGRTMIN+3. PHP's pcntl_signal(SIGRTMIN+3, ...) clobbers it; embedders whose own Go code uses that signal must patch the constant. glibc NPTL reserves SIGRTMIN..SIGRTMIN+2. Drain integration: drainWorkerThreads waits drainGracePeriod (5s) for each thread to reach Yielding, then arms force-kill on stragglers and keeps waiting until they yield. phpThread.shutdown does the same. There is no abandon path: if a thread is stuck in a syscall force-kill cannot interrupt (macOS, Windows non-alertable Sleep) the drain blocks until the syscall returns naturally - matching pre-patch behaviour exactly, just typically much faster because force-kill cuts a 60s sleep down to milliseconds. Operators that want a harder bound rely on their orchestrator (systemd, k8s, supervisord) to SIGKILL the process. worker_test.go + testdata/worker-sleep.php exercise the full path: the test marks a file before sleep(60), polls until the worker is proven parked, then asserts RestartWorkers completes within the grace period and that the post-sleep echo never runs (which would mean the VM interrupt was never observed).
… 'thread-handler-drain-seam' into bg-worker-integration * persistent-zval-helpers: feat: persistent-zval helpers (deep-copy zval trees across threads) * thread-handler-drain-seam: refactor: add drain() seam to threadHandler interface
Fifth step of the split suggested in php#2287. Builds on the minimal background worker from step 4: - PHP function frankenphp_ensure_background_worker(string $name, float $timeout = 30.0): void. Lazy-starts the named worker if not already running, waits for it to publish its first vars, returns void on success and throws on timeout / boot failure. - Two-mode semantics: - Bootstrap (called from an HTTP worker's boot phase, before frankenphp_handle_request): fail-fast. Watches sk.bootFailure on a 50ms ticker alongside ready/aborted/deadline so a broken dependency visibly fails the HTTP worker instead of serving degraded traffic. - Runtime (inside frankenphp_handle_request, classic request path): tolerant. Waits up to the timeout, letting the restart-with- backoff cycle recover from transient boot failures. - Registry + lookup layer: - backgroundWorkerRegistry tracks the template options (env, watch, maxConsecutiveFailures, requestOptions) from one declaration plus its live instances. Catch-all registries have a maxWorkers cap. - backgroundWorkerLookup holds a name map + a single catch-all slot. - reserve() atomic insert-or-return-existing; abortStart() wakes ensure waiters via a new aborted channel so a reserve/abandon race can't hang them until deadline. - Catch-all worker: a name-less bg declaration matches any ensure() name at runtime, subject to max_threads (default 16). Caddyfile support: `worker { background; file ... }` without `name`. - Named lazy path: a num=0 named declaration defers thread attach until ensure() asks for it; the worker struct created at init is reused rather than duplicated. - Boot-failure enrichment: bootFailureInfo now carries the captured PG(last_error_*) ("<msg> in <file> on line <n>"), grabbed on the C side before php_request_shutdown clears it. Ensure's timeout error surfaces it. - $_SERVER['FRANKENPHP_WORKER_NAME'] and $argv[1] are now populated for background workers so catch-all instances can tell which instance they are. - calculateMaxThreads reserves per-bg-worker thread budget separately from the HTTP worker count, scaling with max_threads on catch-alls, so lazy starts have room to schedule. - TestEnsureBackgroundWorkerNamedLazy: num=0 named declaration, ensure() from a non-worker request starts it + reads its vars. - TestEnsureBackgroundWorkerCatchAll: two ensures with distinct names against a single catch-all declaration; each publishes its own identity via $_SERVER. - TestEnsureBackgroundWorkerCatchAllCap: max_threads=2 on the catch- all; third distinct name hits the cap error. - TestEnsureBackgroundWorkerUndeclared: ensure() on a name that is neither named nor covered by a catch-all returns the config error. - Step-4 tests (TestBackgroundWorker, TestBackgroundWorkerErrorPaths, TestBackgroundWorkerRestartForceKillsStuckThread) still pass. - Batch name support on ensure (string[] argument): follow-up. - Per-php_server scoping (BackgroundScope): step 6. - Pools (num > 1, named-worker max_threads > 1) and multi-entrypoint: step 7.
Sixth step of the split suggested in php#2287. Builds on step 5: - BackgroundScope opaque type (int under the hood; obtain values via NextBackgroundWorkerScope, a counter). Zero is the global/embed scope; each php_server block gets a distinct non-zero scope. - Per-scope lookups: - backgroundLookups map[BackgroundScope]*backgroundWorkerLookup replaces the single global backgroundLookup. - buildBackgroundWorkerLookups iterates the declared bg workers into their scope's lookup; each declaration still gets its own registry. - getLookup(thread) resolves the active scope from the calling thread: worker handler -> request context -> global (0). - Options to drive the scope: - frankenphp.WithWorkerBackgroundScope(scope) tags a declaration with a scope. - frankenphp.WithRequestBackgroundScope(scope) tags a request so ensure/get_vars from a regular (non-worker) request resolve to the right block's lookup. - Caddy wiring: FrankenPHPModule.Provision allocates one scope per module instance (idempotent across re-provisions) and threads it into both worker declarations and ServeHTTP. Two php_server blocks can now declare background workers with the same user-facing name without colliding. - Global workersByName collision dropped for bg workers: bg workers resolve through their scope's lookup, so the same PHP-visible name can appear in two scopes without tripping the duplicate check. ## Tests - TestBackgroundWorkerScopeIsolation declares two bg workers named "shared" in distinct scopes, publishes distinct markers from each, and reads them back via scope-tagged requests. Confirms lookups resolve independently. All step-4 and step-5 tests still pass. ## Deferred to step 7 - Pools (num > 1 per named worker, max_threads > 1 for named workers). - Multiple declarations sharing one entrypoint file in one scope.
Seventh (final in the split) step of the split suggested in php#2287. Lifts the remaining constraints from the minimal path: - Pools: named bg workers can now declare num > 1 (pool of threads per worker) and max_threads > 1. Each thread in the pool shares the same backgroundWorkerState, so set_vars / get_vars are scoped per-worker-name, not per-thread. - Per-thread stop-pipe: the write fd moved from worker to handler. Each thread in a pool gets its own stop pipe, so drain() can wake them independently. Pools no longer overwrite one another's fd through the shared worker struct. - Multi-entrypoint: multiple named bg workers in the same scope can share the same entrypoint file. Each gets its own registry from buildBackgroundWorkerLookups, so they inherit independent env/watch/failure-policy options. Drops the filename-uniqueness check for bg workers (it was already skipped via allowPathMatching, but this step lifts the last Caddyfile-level rejection). - Caddyfile: `num > 1` and `max_threads > 1` on named background workers no longer error out. Catch-all semantics unchanged: max_threads caps lazy-started instance count. ## Tests - TestBackgroundWorkerPool: num=3 pool, verifies all threads boot and share state through set_vars/get_vars. - TestBackgroundWorkerMultiEntrypoint: two named bg workers sharing one entrypoint file resolve to distinct instances by name. All previous bg worker tests still pass.
Eighth step on top of php#2287's split. User-facing polish on the ensure API plus a small $_SERVER flag, both landing together because they are small and closely related to the worker-handling surface. - frankenphp_ensure_background_worker now accepts string|array. The array form shares one deadline across all names and preserves the same mode semantics (fail-fast in HTTP-worker bootstrap, tolerant everywhere else). Empty arrays and non-string elements raise clear ValueError / TypeError instead of silent no-ops or cryptic failures. - $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] = true in background worker scripts, alongside the existing FRANKENPHP_WORKER_NAME and argv/argc wiring. Gives scripts a single-key branch for "am I a bg worker?" without checking each function independently. ## Tests - TestEnsureBackgroundWorkerBatch: three workers ensured in one call, each publishing its own name, all read back after the batch returns. - TestEnsureBackgroundWorkerBatchEmpty: [] rejected with ValueError. - TestEnsureBackgroundWorkerBatchNonString: ['a', 42] rejected with TypeError before any worker starts. - TestBackgroundWorkerServerFlag: bg worker sees FRANKENPHP_WORKER_BACKGROUND=true in $_SERVER. ## Deferred - CLI-mode function hiding was in the sidekicks draft but turned out to be dead code (the frankenphp PHP module isn't loaded in CLI, so the functions don't exist there either). - C-side per-request get_vars cache: step 9 (needs benchmarks). - Docs: step 10 (will cover the final API including batch ensure).
Ninth step on top of php#2287's split. Adds a C-side per-request cache keyed on the background worker's vars version so repeated get_vars reads within one request run at O(1) and return the same HashTable pointer. ## What - __thread HashTable *bg_vars_cache maps worker name -> { version, cached_zval }. Initialized lazily on first get_vars call per request. Destroyed before php_request_shutdown tears down request memory, so the cached zvals are torn down while their backing request-memory structures are still alive. - go_frankenphp_get_vars grew callerVersion / outVersion out-params: - If callerVersion matches the live varsVersion, Go skips the deep copy entirely and only reports outVersion. The C side reuses its cached zval (with ZVAL_COPY for refcount bump). - If versions differ, Go runs the normal copy-under-RLock path and reports the fresh version for the caller to cache. - PHP_FUNCTION(frankenphp_get_vars) consults the cache before calling Go, then either reuses the cached zval (hit) or stores the fresh copy (miss). Identity is preserved: $vars === $prev_vars holds across reads within one request. ## Tests - TestGetVarsCacheIdentity: two reads in one request return the same zval (=== true). - TestGetVarsCacheManyReads: 500 reads in one script complete without memory corruption, proving the cache tear-down at request end is correct. All 16 existing bg worker tests still pass.
Covers the full public API landed across the preceding steps: the named/catch-all Caddyfile configuration, the two-mode frankenphp_ensure_background_worker() semantics (fail-fast at HTTP bootstrap, tolerant elsewhere) and its batch form, the pure-read frankenphp_get_vars(), frankenphp_set_vars() with its allowed value types (scalars, nested arrays, enum cases), the signaling stream via frankenphp_get_worker_handle(), and runtime behaviour (dedicated threads, $_SERVER flags, crash recovery with stale vars, 5-second grace period followed by force-kill, per-php_server scoping, and the pool / multi-entrypoint limits).
Note
Description updated to reflect the latest pushes. API names and semantics are final pending review; see the thread for the back-and-forth that led here.
Summary
Background workers are long-running PHP workers that run outside the HTTP cycle. They observe their environment (Redis, DB, filesystem, etc.) and publish variables that HTTP threads (workers or classic requests) read per-request, enabling real-time reconfiguration without restarts or polling.
PHP API
Four functions:
frankenphp_ensure_background_worker(string|array $name, float $timeout = 30.0): void— declares a dependency on one or more background workers. Lazy-starts them if needed, blocks until each has calledset_vars()at least once or the timeout expires. Two behaviors depending on caller:frankenphp_handle_request): fail-fast. Any boot failure throws immediately with the captured details instead of waiting for the backoff cycle. Use for strict dependency declaration at boot.frankenphp_handle_request, classic request-per-process): tolerant lazy-start. First caller pays the startup cost; later callers see the worker already reserved. Processes only start workers they actually exercise.frankenphp_set_vars(array $vars): void— publishes vars from a background worker script (persistent memory, cross-thread). Skips all work when data is unchanged (===check).frankenphp_get_vars(string $name): array— pure read. Returns the latest published vars. Throws if the worker isn't running or hasn't calledset_vars()yet. Generational cache: repeated calls within a single HTTP request return the same array instance (===is O(1)).frankenphp_get_worker_handle(): resource— readable stream for shutdown signaling. Closed on shutdown (EOF).In CLI mode (
frankenphp php-cli), none of these functions are exposed (MINIT-level hiding viazend_hash_str_del).function_exists()returnsfalse, so library code can degrade gracefully.Caddyfile configuration
backgroundmarks a worker as non-HTTPnamespecifies an exact worker name; workers withoutnameare catch-all for lazy-started namesmax_threadson catch-all sets a safety cap for lazy-started instances (defaults to 16)max_consecutive_failuresdefaults to 6 (same as HTTP workers)max_execution_timeautomatically disabled for background workersphp_serverblock has its own isolated scope (opaqueBackgroundScopetype managed byfrankenphp.NextBackgroundWorkerScope())Shutdown
On restart/shutdown, the signaling stream is closed. Workers detect this via
fgets()returningfalse(EOF). Workers have a 5-second grace period. In-flightensure_background_workercalls unblock onglobalCtx.Done()instead of waiting out their timeout.After the grace period, a best-effort force-kill is attempted:
max_execution_timetimer cross-thread viatimer_settime(EG(max_execution_timer_timer))CancelSynchronousIo+QueueUserAPCinterrupts blocking I/O and alertable waitsDuring the restart window,
get_varsreturns the last published data (stale but available, kept in persistent memory across restarts). A warning is logged on crash.Boot-failure reporting
When a background worker fails before calling
set_vars,ensure_background_workerthrows aRuntimeExceptionwith the captured details: worker name, resolved entrypoint path, exit status, number of attempts, and the last PHP error (message, file, line) captured fromPG(last_error_*).Forward compatibility
The signaling stream is forward-compatible with the PHP 8.6 poll API RFC.
Poll::addReadableaccepts stream resources directly; code written today withstream_selectwill work on 8.6 withPoll, no API change needed.Architecture
php_serverscope isolation via opaqueBackgroundScopetype. Internal registry is unexported.backgroundWorkerThreadhandler implementingthreadHandlerinterface, decoupled from HTTP worker code paths.drain()closes the signaling stream (EOF) for clean shutdown signaling.pemalloc) withRWMutexfor safe cross-thread sharing.set_varsskip: uses PHP's===(zend_is_identical) to detect unchanged data, skips validation, persistent copy, write lock, and version bump.IS_ARRAY_IMMUTABLE).ZSTR_IS_INTERNED): skip copy/free for shared-memory strings.ensure_background_workeraccepts a batch of names with a shared deadline; fail-fast in bootstrap mode reports the failing worker's details.$_SERVER['FRANKENPHP_WORKER_NAME']set for background workers.$_SERVER['FRANKENPHP_WORKER_BACKGROUND']set for all workers (true/false).Example
Test coverage
Unit tests, integration tests, and one Caddy integration test covering: bootstrap fail-fast, runtime tolerant lazy-start, multi-name ensure, get_vars pure read, set_vars validation (types, objects, refs), CLI function hiding, enum support, binary-safe strings, multiple entrypoints, crash-restart reclassification, boot-failure rich errors, signaling stream, worker restart lifecycle, named auto-start with
m#prefix, edge cases (empty name, negative timeout, timeout=0).All tests pass on PHP 8.2, 8.3, 8.4, and 8.5 with
-race. Zero memory leaks on PHP debug builds.Documentation
Full docs at
docs/background-workers.md.